SPB Git forge

spb/countryatlas

Public
20commits 1branches 0releases
268.3 MBsize
maindefault branch
12 days agolast push
TypeScript 57% Python 38.6% JavaScript 3.6% CSS 0.6%
8.7 KB · 153 lines tsx
Raw Blame History
1import { Download, ExternalLink } from 'lucide-react';2import type { Metadata } from 'next';3import Link from 'next/link';4import { notFound } from 'next/navigation';5import { t } from '@/i18n';6import { api, isNotBuilt, isNotFound, safe } from '@/lib/api';7import { apiAnalytics } from '@/lib/api-analytics';8import { apiCompare } from '@/lib/api-compare';9import { grouped } from '@/lib/format';10import { parseRankingState, type RankingState } from '@/lib/ranking-state';11import { jsonLd, jsonLdString, seoTitle } from '@/lib/seo';12import { routes } from '@/lib/site';13import { topicById } from '@/lib/topics';14import type { RankingResponse } from '@/lib/types';15import { toCountryLite, type CountryLite } from '@/lib/types-compare';16import { RankRace } from '@/components/charts/rank-race';17import { NotBuiltState } from '@/components/data/empty-state';18import { Section } from '@/components/data/section';19import { baseFeatures } from '@/components/indicators/map-geometry';20import { RankHistoryPanel } from '@/components/rankings/rank-history-panel';21import { RankingView } from '@/components/rankings/ranking-view';2223export const revalidate = 900;2425type Params = { indicator: string };26type SP = Record<string, string | string[] | undefined>;27/** Indicators with a rank race on their page (long histories, ≥ 30 ranked years). */28const RACE_SLUGS = new Set(['gdp', 'gdp-ppp', 'population', 'gdp-per-capita', 'gdp-per-capita-ppp', 'life-expectancy', 'co2-emissions', 'co2-per-capita', 'internet-users', 'exports-goods-services', 'urban-population-share', 'fertility-rate', 'median-age']);2930async function load(slug: string, state: RankingState): Promise<(RankingResponse & { label?: string }) | 'not-built' | null> {31  if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) return null;32  try {33    return await apiCompare.ranking(slug, { year: state.year, group: state.group, sort: state.sort, limit: 300, sparkline: true });34  } catch (e) {35    if (isNotFound(e)) return null;36    if (isNotBuilt(e)) return 'not-built';37    throw e;38  }39}4041export async function generateMetadata({ params, searchParams }: { params: Promise<Params>; searchParams: Promise<SP> }): Promise<Metadata> {42  const [{ indicator }, sp] = await Promise.all([params, searchParams]);43  const state = parseRankingState(sp);44  const data = await load(indicator, state);45  if (!data || data === 'not-built') return { title: t('ranking.notFound'), robots: { index: false } };46  const name = data.indicator.short_name ?? data.indicator.name ?? indicator;47  const year = data.year_used ?? '';48  const world = state.group === 'world';49  const title = world ? seoTitle.ranking(name, year) : t('ranking.pageTitleGroup', { name, group: data.group.name ?? state.group, year });50  const top = data.rows51    .slice(0, 3)52    .map((r) => r.country.name)53    .filter(Boolean)54    .join(', ');55  const description = t('ranking.pageDescription', { name: data.indicator.name ?? name, n: grouped(data.n), year, top });56  const canonical = routes.ranking(data.indicator.slug, { group: state.group });57  const volatile = state.year != null || state.highlight || state.q || state.income || state.minpop || state.mincov || state.view !== 'table';58  return {59    title: { absolute: `${title} | ${t('site.name')}` },60    description,61    alternates: { canonical },62    robots: volatile ? { index: false, follow: true } : undefined,63    openGraph: { title: `${title} | ${t('site.name')}`, description, url: canonical, type: 'article' },64    twitter: { card: 'summary_large_image', title, description },65  };66}6768export default async function RankingPage({ params, searchParams }: { params: Promise<Params>; searchParams: Promise<SP> }) {69  const [{ indicator }, sp] = await Promise.all([params, searchParams]);70  const state = parseRankingState(sp);71  const data = await load(indicator, state);72  if (data === null) notFound();73  if (data === 'not-built') return <NotBuiltState />;7475  const ind = data.indicator;76  const name = ind.short_name ?? ind.name ?? indicator;77  const year = data.year_used;78  const highlightRow = state.highlight ? data.rows.find((r) => (r.country.slug ?? '') === state.highlight) : null;79  const historyIds = state.history.length ? state.history : Array.from(new Set([...data.rows.slice(0, 3).map((r) => r.country.id), ...(highlightRow ? [highlightRow.country.id] : [])])).slice(0, 5);80  const wantRace = RACE_SLUGS.has(ind.slug) || !!ind.featured;8182  const [regions, countriesRes, history, race] = await Promise.all([83    safe(apiCompare.regions()),84    safe(api.countries()),85    historyIds.length ? safe(apiCompare.rankHistory(ind.slug, historyIds)) : Promise.resolve(null),86    wantRace ? safe(apiAnalytics.race(ind.slug, { top: 10, group: state.group !== 'world' ? state.group : null })) : Promise.resolve(null),87  ]);88  const all = (countriesRes?.items ?? []).filter((c) => c.kind !== 'aggregate');89  const countries: CountryLite[] = all.map(toCountryLite).sort((a, b) => a.name.localeCompare(b.name));90  const { features, sphere } = all.length ? baseFeatures(all) : { features: [], sphere: '' };91  const topic = topicById(ind.topic ?? '');92  const groupName = data.group.name ?? state.group;93  const spec = { format: ind.format, unit: ind.unit, unit_short: ind.unit_short, precision: ind.precision, name, higher_is_better: ind.higher_is_better };94  const showRace = race && race.frames.length >= 30;95  const ld = [jsonLd.breadcrumbs([{ name: t('rankings.title'), path: routes.rankings() }, { name, path: routes.ranking(ind.slug) }]), jsonLd.dataset({ slug: ind.slug, name: ind.name ?? name, unit: ind.unit, nCountries: data.n, modified: data.meta.built_at })];9697  return (98    <>99      <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: jsonLdString(ld) }} />100      <header className="pb-3 pt-6 md:pt-10">101        <nav aria-label="Breadcrumb" className="-my-3 text-xs text-ink-3 md:my-0">102          <Link href={routes.rankings()} className="inline-flex min-h-[44px] items-center hover:text-accent md:min-h-0">103            {t('rankings.title')}104          </Link>105          {topic ? (106            <>107              <span className="mx-1.5">/</span>108              <Link href={`${routes.rankings()}#topic-${topic.id}`} className="inline-flex min-h-[44px] items-center hover:text-accent md:min-h-0">109                {topic.short}110              </Link>111            </>112          ) : null}113        </nav>114        <h1 className="display mt-2 text-3xl leading-tight text-ink md:text-4xl">{state.group === 'world' ? t('ranking.heading', { name }) : t('ranking.headingGroup', { name, group: groupName })}</h1>115        <p className="tnum mt-2 text-sm text-ink-2">116          {ind.name && ind.name !== name ? <span className="text-ink">{ind.name} · </span> : null}117          {t('ranking.definition', { unit: ind.unit ?? '', n: grouped(data.n), year: year ?? t('common.na') })}118        </p>119        <div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-sm">120          <Link href={routes.indicator(ind.slug)} className="inline-flex min-h-[44px] items-center gap-1 text-accent hover:underline md:min-h-[32px]">121            {t('ranking.openIndicator')}122            <ExternalLink size={13} aria-hidden />123          </Link>124          <Link href={routes.explore({ indicator: ind.slug, year })} className="inline-flex min-h-[44px] items-center text-accent hover:underline md:min-h-[32px]">125            {t('home.map.openExplorer')}126          </Link>127          <a href={routes.indicatorDownload(ind.slug)} download className="inline-flex min-h-[44px] items-center gap-1 text-ink-2 hover:text-accent hover:underline md:min-h-[32px]">128            <Download size={13} aria-hidden />129            {t('ranking.download')}130          </a>131          {data.rows.length >= 2 ? (132            <Link href={routes.compare(...data.rows.slice(0, Math.min(5, data.rows.length)).map((r) => r.country.slug ?? r.country.id.toLowerCase()))} className="inline-flex min-h-[44px] items-center text-accent hover:underline md:min-h-[32px]">133              {t('ranking.compareTop', { n: Math.min(5, data.rows.length) })}134            </Link>135          ) : null}136        </div>137      </header>138139      <RankingView data={data} regions={regions?.items ?? []} countries={countries} state={state} geometry={features} sphere={sphere} />140141      {showRace ? (142        <Section id="race" title={t('ranking.race.title')} subtitle={t('ranking.race.sub', { name, y0: race.years[0] ?? '', y1: race.years[race.years.length - 1] ?? '', top: race.top })}>143          <RankRace data={race} spec={spec} top={10} initialYear={year} />144        </Section>145      ) : null}146147      <Section id="history" title={t('ranking.history.title')} subtitle={t('ranking.history.sub', { max: 5 })} level={showRace ? 3 : 2}>148        <RankHistoryPanel slug={ind.slug} countries={countries} initialIds={historyIds} initial={history} />149      </Section>150    </>151  );152}153